Skip to content

feat(deepnote): one notebook per .deepnote file - #429

Merged
tkislan merged 97 commits into
mainfrom
tk/single-notebook
Jul 17, 2026
Merged

tkislan merged 97 commits into
mainfrom
tk/single-notebook

Conversation

@tkislan

@tkislan tkislan commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Migrates the extension to a single notebook per .deepnote file model, removing the multi-notebook-per-file machinery (the ?notebook=<id> URI selection and its timers/manager state).

What changes

  • Deserialize renders one notebook — the first non-init notebook (falling back to the init only when it is the file's only notebook); serialize resolves the target from document metadata with an exact (projectId, notebookId) lookup.
  • Legacy multi-notebook files are split on demand — opening one shows a notification that writes one new single-notebook file per notebook, migrates the environment selection, then deletes the original (write-before-delete; the original is never lost on failure).
  • New / duplicated notebooks become sibling files (never appended into one file).
  • Project metadata fans out across siblings — integration/rename edits are written to every sibling .deepnote of the project on disk (open or closed), with file-watcher self-write suppression.
  • Explorer is grouped by project (ProjectGroup → file → notebook) plus a status bar showing the active notebook with a "Copy details" command.
  • Server & environment are keyed per notebook (notebook.uri.toString(), 1:1 with the kernel/controller). Environment deletion now actually stops every server using it (including closed-but-running ones), fixing a real bug.
  • Snapshots are notebook-scoped with a backward-compatible reader (legacy project-scoped snapshots still load as a fallback and are never migrated) and a deferred, output-settled save.
  • Init notebook runs per kernel from its sibling file — re-running after an in-place restart (tracked in a WeakSet<IKernel>, not a persistent project flag).

Review hardening & polish

Since the initial build, the branch has been through review and a correctness / UX pass:

  • Rename saves open, dirty notebooks first and aborts if a save is declined — unsaved cell edits are no longer clobbered by the watcher reload.
  • Integrations persist to every sibling deterministically (enumerated on disk), so ownership no longer depends on which files happen to be open.
  • Split rolls back its side effects on any post-write failure (generated files, env mappings, rename) and reports the outcome accurately instead of falsely reporting "unchanged".
  • Snapshot discovery is scoped to the requested notebook before the 200-file cap, so a sibling's snapshots can't push the notebook's own latest out of the window.
  • Add Notebook keeps the project group expanded (scoped refresh, no full-tree teardown) and names the new file {project}-{notebook}.deepnote from the project name instead of accumulating sibling names.
  • Explorer icons — single-notebook files and notebook children use file-code; legacy multi-notebook files use book and carry a "(legacy)" tooltip (the old notebook codicon rendered as a clock at tree size).
  • DeepnoteTreeDataProvider is constructor-injected for testability, and the split prompt now flags the multi-notebook layout as legacy.

How it was built

95 commits: the feature first landed in 7 sequential, independently-reviewed chunks, followed by the review-driven hardening pass above, a full ExTester E2E suite, and a comment / style cleanup.

Verification

  • Static: tsc clean (0 errors), eslint clean, prettier --check clean, both esbuild bundles build.
  • Unit: 2485 passing, 234 pending, 0 failing.
  • E2E (ExTester, headless VS Code 1.111): explorer grouping, legacy split (multi / init / safety), rename (including the open-dirty case), new / add / duplicate / delete, snapshots, integrations, status bar, init-notebook runner, file watcher. The rename, notebook-command, and icon flows were re-verified against a rebuilt VSIX.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ

Summary by CodeRabbit

  • New Features
    • Deepnote Explorer now groups .deepnote files by project and shifts key actions to notebook-scoped workflows (create/duplicate/rename/delete/import/export), with project renaming staying group-level.
    • Opening legacy multi-notebook files can split them into per-notebook sibling files (including environment mapping migration when available).
    • Added a status bar item to copy active Deepnote notebook details.
  • Bug Fixes
    • Improved per-notebook isolation across SQL environments, server lifecycle, integrations, and snapshots to prevent cross-notebook leakage.
    • Fixed indented bullet text cell rendering.
    • Environment deletion now stops servers before removing mappings for safer cleanup.
  • Tests
    • Expanded unit and end-to-end coverage for explorer behavior, splitting, integrations, snapshots, and environment handling.

tkislan and others added 8 commits June 23, 2026 22:23
…d) + add project-id resolver

Chunk 1 of single-notebook migration (§4 partial, §5). No behaviour change.

- Manager caches originals in a nested Map<projectId, Map<notebookId, project>>
  so sibling files sharing a project.id no longer clobber each other.
- New API: getOriginalProject(projectId, notebookId) exact/no-fallback,
  getAnyProjectEntry(projectId), storeOriginalProject/updateOriginalProject
  (3-arg), updateProjectIntegrations iterates all entries.
- Update IDeepnoteNotebookManager and IPlatformDeepnoteNotebookManager; repoint
  all project-level read-only callers to getAnyProjectEntry.
- Add canonical readDeepnoteProjectFile and resolveProjectIdFor{File,Notebook}.
- Selection state and init-run tracking intentionally kept (removed in later chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…rop selection machinery

Chunk 2 of single-notebook migration (§1 + Cleanup).

- deserializeNotebook renders the first non-init notebook (findDefaultNotebook),
  falling back to the only/init notebook; never composes init.
- serializeNotebook resolves the target from document metadata alone (projectId +
  notebookId required) and looks it up with the exact getOriginalProject, throwing
  clear errors instead of falling back to a wrong sibling.
- detectContentChanges collapses to a single-notebook comparison.
- Remove the ?notebook=<id> selection machinery: findCurrentNotebookId, the
  manager's selection state + interface methods, the explorer's query-param opens
  and selectNotebookForProject calls, and the tree item's custom resourceUri.
- Explorer no longer depends on IDeepnoteNotebookManager.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…k siblings

Chunk 3 of single-notebook migration (§0, §2, §3).

- Add allocateSiblingUri: the single filesystem-aware, collision-safe sibling
  filename allocator (bumps -2/-3 before .deepnote, honors an in-batch reserved
  set, bounded retries).
- Add a notebook file factory (buildSingleNotebookFile / buildSiblingNotebookFileUri)
  for creating sibling single-notebook files (wired into the explorer in a later
  chunk).
- Add DeepnoteMultiNotebookSplitter: on opening a multi-notebook .deepnote file,
  offer to split it into one new single-notebook file per notebook. The action
  flushes the editor if dirty, writes all children, migrates the environment
  selection, then closes the tab and deletes the original to trash. A child-write
  failure leaves the original intact (write-before-delete).
- Wire the splitter into activation with an optional (desktop-only) environment
  mapper; add a refresh() passthrough on the explorer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
Chunk 4 of single-notebook migration (§6).

- Add DeepnoteProjectMetadataPropagator (desktop): given a project id and a
  project-level mutator, enumerate every sibling .deepnote file on disk (open or
  closed), apply the change, and write it back. Skips no-op writes, refreshes the
  manager cache for open siblings, and collects per-file failures instead of
  aborting. Fires an onFileWritten hook so the file watcher treats each write as a
  self-write (no reload/save storm).
- Route integration updates and project rename through the propagator so closed
  siblings stay consistent; web falls back to the cache-only / single-file paths.
- Expose getOriginalProject/updateOriginalProject on the platform manager interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…us bar

Chunk 5 of single-notebook migration (§7).

- Tree is grouped: ProjectGroup (by project id) -> ProjectFile -> Notebook. A
  single-notebook file is a leaf labelled with its notebook; legacy multi-notebook
  files stay collapsible. The init notebook is excluded from counts everywhere.
- Refresh is grouping-safe: refreshNotebook evicts every sibling cache entry for a
  project id and all refreshes fire a full-tree change (no per-item fires).
- Commands are project-scoped vs notebook-scoped; new/duplicate/add-notebook create
  sibling files via the factory (never appended), delete removes the file for a
  single-notebook file, and notebook names are unique within a project group.
- Add a status bar item showing the active Deepnote notebook with a
  "Copy Active Deepnote Notebook Details" command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
Chunk 6 of single-notebook migration (§8).

- Key the server starter maps, the config handle, and the kernel auto-selector by
  notebook.uri.toString() - the same identity the kernel and controller use - so a
  notebook's server is 1:1 with its kernel. Sibling notebooks of one project no
  longer share a server; the working directory and SQL env are taken from each
  notebook's own file.
- Fix environment deletion: stop every server using the environment (including
  closed notebooks whose server is still running) before removing the mappings,
  driven from the notebook->environment mapper. Drop the dead environmentServers
  map that was never populated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…le reader

Chunk 7a of single-notebook migration (§9).

- Write snapshots with notebook-scoped filenames via @deepnote/convert
  (generateSnapshotFilename / parseSnapshotFilename), replacing the local slug and
  filename regex.
- readSnapshot resolves snapshots path-free (it runs at deserialize, which has no
  URI): glob by project id, rank the notebook-scoped match first and keep legacy
  project-scoped snapshots as a fallback, and skip an empty-output "latest" (save
  race) or a corrupt file while walking candidates. Legacy snapshots are read, never
  migrated or deleted.
- Defer the execution snapshot save until outputs settle (quiet window with a max
  wait) and cancel it on re-execute / close.
- Use convert's computeSnapshotHash on the save path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
Chunk 7b of single-notebook migration (§10).

- The init runner now subscribes to kernel start and restart events and runs the
  init notebook found in its own sibling .deepnote file (matched by project id +
  initNotebookId via isValidSiblingInitCandidate), instead of looking it up in the
  main file's notebooks.
- Track "init has run" per kernel in a WeakSet<IKernel>: a fresh kernel runs init
  once, and an in-place restart (which fires onDidRestartKernel) re-runs it so the
  kernel is re-initialized before the next user cell. A missing sibling is logged
  and skipped without permanently marking the project.
- Remove the manager's persistent init-run tracking and the selector's init
  staging; the runner owns init triggering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
@codecov

codecov Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0%. Comparing base (6363487) to head (94f471d).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@     Coverage Diff     @@
##   main   #429   +/-   ##
===========================
===========================
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

tkislan and others added 21 commits June 24, 2026 06:54
- void the fire-and-forget onExecutionComplete call (no-floating-promises),
  matching the existing void performSnapshotSave pattern.
- Use American "behavior" in a comment.
- Add test-only technical words (basenames, initmain, Résumé, unparseable) to cspell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…of duplicating it

The mocha ESM loader wholesale-mocked @deepnote/convert and reimplemented its pure
helpers (resolveSnapshotNotebookId, splitByNotebooks, isValidSiblingInitCandidate,
snapshot filename generate/parse, hashing, etc.). That duplicated upstream logic
with no drift detection: if convert changed, the mock silently kept the old
behavior and tests stayed green against a fiction.

- Remove the @deepnote/convert interception from build/mocha-esm-loader.js so unit
  tests exercise the real package's pure functions (and now track its actual API).
- Mock only the one genuinely side-effecting export, convertIpynbFilesToDeepnoteFile
  (real node:fs I/O), via esmock in the explorer import suites where it is used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…r paths

From the Codex review of the PR (F1/F3/F4/F5), all independently verified:

- F1 (P1): snapshot save fetched the cached project with getAnyProjectEntry(projectId),
  which can return the wrong sibling when multiple single-notebook siblings of one
  project are open, silently skipping the snapshot write. Use the exact
  getOriginalProject(projectId, notebookId) lookup instead.
- F3: collectNotebookNamesForProject globbed **/*.deepnote without skipping snapshot
  sidecars, so stale snapshot notebook names polluted the name-uniqueness set. Filter
  snapshot files (matching the tree provider and propagator).
- F4: detectContentChanges compared notebooks[0]; for a legacy [init, main] file the
  edited notebook is not at index 0, so edits were missed and modifiedAt preserved.
  Match the notebook by id.
- F5: the deferred-save timer fired performSnapshotSave as a floating promise; wrap the
  save body in try/catch/finally so a build/write failure is logged (not an unhandled
  rejection) and execution state is always cleared.

Adds regression tests for F1 (exact lookup), F3 (snapshot exclusion), and F4 (match by id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…el init runs

Addresses round-2 code-review findings G2 and G3 (both verified P2).

- G2: deepnoteFileChangeWatcher's snapshot block-id recovery used the project-only
  getAnyProjectEntry(projectId), which can return a different open sibling's cached
  project (siblings share project.id), leaving originalBlocks undefined and silently
  skipping recovered outputs. Use the exact getOriginalProject(projectId, notebookId)
  — the same fix already applied to snapshotService (F1), here in the watcher path
  that was missed.
- G3: moving init execution to the event-driven runner dropped the notebook-close
  cancellation that the kernel auto-selector used to provide, so closing a notebook
  mid-init left the remaining init blocks executing against a closed notebook. Tie the
  init run to a CancellationTokenSource cancelled on notebook close and dispose it in
  a finally.

Adds regression tests for both (each fails on the pre-fix code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro
…ort/delete

Removes two pieces of functionality; also folds in the branch's in-progress
updates this work was layered on top of (they could not be isolated, as the
removals are interleaved with and built on top of that WIP).

Removed - project-metadata propagator:
- Delete DeepnoteProjectMetadataPropagator and its types, drop the DI binding,
  and unwire it everywhere (activation, file-change watcher self-write hook,
  integration webview, explorer rename). Project-level fields are no longer
  fanned out across sibling files: each notebook owns its own integrations, and
  project-name drift is accepted for now. Drop the now-dead updateOriginalProject
  manager method and two stale comments.

Removed - project-level explorer commands:
- Delete the exportProject and deleteProject commands (constants, command-arg
  type, registrations, package.json command defs + sidebar menus, nls titles,
  and their unit tests). Per-notebook export remains via the existing
  exportNotebook command (first non-init notebook of the file).

Also includes the branch's pending updates the above was built on: dependency
bumps (incl. @deepnote/convert 4.0), the getOriginalProject ->
getProjectForNotebook manager rename and getAnyProjectEntry removal, and
assorted snapshot/serializer/kernel adjustments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Brings in #432 (Cloud SQL integration support). Resolved the package.json and
package-lock.json conflicts by keeping this branch's newer @deepnote/* versions
(blocks 4.6.0, convert 4.0.0, runtime-core 0.4.0); @deepnote/database-integrations
is 1.5.0 on both sides, and the Cloud SQL source from #432 merged cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
@deepnote/blocks@4.6.0+ renders `text-cell-bullet` blocks with
`indent_level >= 1` using leading spaces (two per level) before the bullet
marker. stripMarkdown's bullet regex only matches at column 0, so the
leading indentation must be trimmed first for the plain-text cell value to
round-trip correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
…es re-exports

snapshotFiles.ts re-exported six snapshot-filename helpers from
@deepnote/convert. Remove the re-export block and import the helpers
directly from @deepnote/convert at each use site (snapshotService.ts and
the snapshotFiles unit test). snapshotFiles.ts now keeps only its local
helpers (SNAPSHOT_FILE_SUFFIX, isSnapshotFile, extractProjectIdFromSnapshotUri)
plus the single internal use of parseSnapshotFilename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Refactor the buildSnapshotPath method to accept an object as an argument, improving readability and maintainability. Update all relevant calls to this method throughout the snapshotService and its unit tests to match the new signature. This change enhances the clarity of parameter usage and reduces the risk of errors when passing arguments.
Trim the single-notebook test suites by removing duplicate and
tautological tests and collapsing/merging several others, shrinking the
PR's test additions by ~640 lines with no loss of real coverage.

Cuts target only tests this branch added:
- exact-(projectId, notebookId)-lookup restatements duplicated across
  the watcher, serializer, snapshot, and manager suites
- wrapper tests already covered by the delegate's own tests
  (addNotebookToProject, sibling-file allocation, project-id resolution)
- tautologies over trivial template/getter functions (serverUtils)
- framework-registration smoke tests (status bar)

Merges keep the one meaningful assertion and drop the duplicate
scaffolding (e.g. legacy-delete no-op folded into the existing delete
test; two init builders parametrized into one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
When splitting a legacy multi-notebook .deepnote file into single-notebook
siblings, rename the original to `<name>.deepnote.legacy` instead of moving it
to the OS trash. The `.legacy` suffix takes it out of the extension's view (it
no longer matches `*.deepnote`) while keeping it on disk next to the split
results, so the user can restore it by removing the suffix.

Unlike `workspace.fs.delete({ useTrash: true })`, this is deterministic and does
not depend on an OS trash backend (which can be absent on headless Linux).
Collisions bump the name to `.legacy-2`, `.legacy-3`, … and the rename still
happens only after every child is durably written (write-before-retire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test that drives the real VS Code UI through the
on-open split of a legacy multi-notebook .deepnote file: it asserts the split
prompt, the one-file-per-notebook result, the retained `.legacy` backup, that
each sibling opens without re-prompting, and that content plus the project
integration fan out into every split file.

Add a `createScreenshotter(this)` helper that captures step screenshots into a
per-spec directory derived from the running test file
(`test/e2e/screenshots/<spec>/`), plus the `sales-analytics.deepnote` fixture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
…ites

Add ExTester end-to-end suites covering:
- opening a plain single-notebook file (opens directly, no split prompt, the
  status bar shows the notebook name);
- splitting a multi-notebook file that declares an init notebook (the init
  notebook becomes its own single-notebook sibling; each main sibling still
  references it via initNotebookId);
- the init-notebook runner: the sibling init notebook runs hidden in a main
  notebook's kernel so its definitions are available, and re-runs after a kernel
  restart.

Add the quick-notes and etl-pipeline fixtures (including the pre-split
extract/init siblings), and disable the kernel-restart confirmation in the E2E
settings so the restart test can drive it non-interactively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test asserting the Deepnote Explorer groups sibling
.deepnote files by project: three files sharing one project.id collapse into a
single "Marketing" group ("3 files") whose leaves are the three notebooks, while
a file from a different project appears as its own group. Reads the tree by
diffing visible leaves before/after expanding (avoids the page-object library's
flaky CustomTreeItem.getChildItems). Adds the three marketing fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
When several E2E suites run in one ExTester session (as in CI, via the
`*.e2e.test.js` glob), every workspace-folder open after the first failed with
"Failed to open folder after 5 attempts" in the suite's `before all` hook — only
the alphabetically-first suite passed.

Root cause: the simple "Open Folder" dialog (files.simpleDialog.enable)
navigates one directory level *toward* the typed path per OK click and only
accepts the folder once the browser is AT it. The helper clicked OK once then
re-opened the dialog each attempt, which reset navigation back to the default
directory — for the 2nd+ open that default is the previous, now-deleted
workspace, so the dialog fell back to "/" and never converged on the target.

Fix: click OK repeatedly within a single dialog until the pre-open workbench
element detaches (reload = folder accepted), instead of re-opening per attempt;
and set `window.openFoldersInNewWindow: "off"` so "Open Folder" reuses the
current window, keeping that reload detectable. Verified with four suites (16
tests) opening four folders in one session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test for the Deepnote status-bar item: it shows the
active notebook's name (with the "Copy Active Deepnote Notebook Details"
tooltip), hides when a non-notebook editor is focused, and — on click — copies
the notebook details to the clipboard with a confirmation toast. The clipboard
is verified by pasting into a scratch text file and reading it back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test for the notebook-management commands that create
and rename sibling .deepnote files from the Deepnote explorer: New Notebook,
Add Notebook (project-group context menu), Duplicate Notebook, and Rename
Notebook — each verified by the resulting notebook name inside the sibling files
plus the confirmation toast. Delete Notebook is included as a pending test: its
context-menu -> native confirmation-modal interaction is unreliable to drive
under ExTester (documented inline), so it is left as a manual check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test for the Deepnote integrations UI: opening
"Manage Integrations" for a notebook whose project declares an integration lists
it (the "Sales BigQuery" integration on the sales-analytics-revenue fixture),
while a plain notebook (quick-notes) shows no such integration. Adds the
sales-analytics-revenue fixture (a single-notebook split of the Sales Analytics
project carrying the BigQuery integration + its SQL cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ
Add an ExTester end-to-end test for renaming a Deepnote project from the
Explorer: none of the three "Marketing" siblings is opened, then the project
group is renamed to "Growth" via its context menu; the new name is asserted to
fan out to every sibling .deepnote file on disk (and the old name gone), with the
Explorer group relabelled and a confirmation toast.

Extract the shared Deepnote tree helpers (getDeepnoteExplorerSection,
readDeepnoteTreeRows, findDeepnoteGroup/Leaf, selectDeepnoteContextMenu) into
test/e2e/helpers/deepnoteTree.ts for reuse across the tree-driven suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/e2e/suite/projectRename.e2e.test.ts (1)

51-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the editor-focus delay.

Extract 400 to a module-level EDITOR_FOCUS_SETTLE_DELAY constant.

Proposed change
 const TREE_LOAD_TIMEOUT = 30_000;
+const EDITOR_FOCUS_SETTLE_DELAY = 400;
 
-    await driver.sleep(400);
+    await driver.sleep(EDITOR_FOCUS_SETTLE_DELAY);

As per coding guidelines, “Extract magic numbers (retry counts, delays, timeouts) as named constants near the top of the module.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/suite/projectRename.e2e.test.ts` at line 51, Extract the 400
millisecond delay used by the editor-focus flow in projectRename.e2e.test.ts
into a module-level constant named EDITOR_FOCUS_SETTLE_DELAY near the top of the
module, then use that constant in the driver.sleep call.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/e2e/suite/projectRename.e2e.test.ts`:
- Line 51: Extract the 400 millisecond delay used by the editor-focus flow in
projectRename.e2e.test.ts into a module-level constant named
EDITOR_FOCUS_SETTLE_DELAY near the top of the module, then use that constant in
the driver.sleep call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 58b71a30-0d25-4305-ab74-8b6030cf99f8

📥 Commits

Reviewing files that changed from the base of the PR and between bd84273 and 9742682.

📒 Files selected for processing (1)
  • test/e2e/suite/projectRename.e2e.test.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
… files

The `notebook` codicon renders as a clock face at tree size (verified against the
codicon set in VS Code 1.111 — it's a real glyph, `\ebaf`, not a fallback), so
every `.deepnote` file showed a clock in the Explorer. Replace it:

- Single-notebook files now use `file-code`, matching the notebook children they
  represent — every "notebook you open" looks the same.
- Legacy multi-notebook files use `book` (a container of notebooks), and their
  tooltip gains a "(legacy)" suffix to mark the retired one-file-many-notebooks
  layout.

Icons rendered and confirmed in a headless VS Code 1.111 (folder / book /
file-code, no clock); tree-item and provider unit-test icon and tooltip
assertions updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
@tkislan

tkislan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

@dinohamzic thanks for the findings .. I've addressed all of them

@tkislan
tkislan requested a review from dinohamzic July 15, 2026 15:22

@dinohamzic dinohamzic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't spot any major issue while manual testing, and the reported ones are fixed, nice one.

Another AI review in case you spot anything relevant:

  1. [P1] Integration updates can report success without reaching disk. projectIntegrationsWriter.ts:84

    cacheUpdated makes the operation succeed even when no files were written—for example, when a .deepnote file is opened outside a workspace folder—or when some sibling writes fail. The webview then posts “Configuration saved successfully,” but integration membership can disappear on reopen or differ across siblings. Persistence should target the active file explicitly and report/throw on incomplete fan-out.

  2. [P2] Snapshot selection is still capped before ranking. snapshotService.ts:909

    The scoped search still returns an arbitrary 200 files before sorting, so after 200 executions the notebook’s latest or newest snapshot can be omitted. The legacy pass is broader and filters sibling-scoped files only after the same cap, so noisy siblings can still hide the legacy fallback. Query exact latest patterns first, then apply retention or a cap after filtering and ordering.

  3. [P2] A failed child write can still leave an orphan. deepnoteMultiNotebookSplitter.ts:155

    Cleanup is registered only after writeFile resolves. Filesystem providers may create or partially write the destination before rejecting, leaving an untracked sibling that causes suffixed duplicates on retry. Register safe cleanup before the write or explicitly probe/delete the current target on failure.

  4. [P2] Splitting proceeds when the original tab failed to close. deepnoteMultiNotebookSplitter.ts:261

    tabGroups.close() returns whether the close succeeded, but the result is ignored. If it returns false, the extension renames the file while its editor remains open; a later save can recreate the legacy .deepnote file alongside the generated siblings. Return/check the boolean and abort before rename.

tkislan and others added 3 commits July 16, 2026 13:55
Saving integrations reported success from the in-memory cache update alone, so a
folderless / out-of-workspace notebook (findFiles covers only open folders) or a
partial sibling fan-out could post "Configuration saved successfully" having
written nothing to the file being edited — integration membership then vanished
on reopen.

persistProjectIntegrations now writes the ACTIVE file explicitly first, then fans
out, and returns { activePersisted, siblingsFailed } judged from disk (not the
cache); the webview only claims success when the active file reached disk and
warns on an incomplete fan-out. persistProjectIntegrations / writeIntegrationsToFile
also take a single params object instead of positional arguments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
findSnapshotOutputs passed maxResults to findFiles, which truncates an UNORDERED
set to 200 BEFORE ranking — so a notebook with >200 of its own snapshots could
have its `latest` dropped (stale outputs on open), and a sibling flood could push
a notebook's legacy fallback out of the window (no outputs on open).

The glob is now uncapped and the 200 bound is applied to the ranked candidate
list, so the best candidate is always considered and only the read loop is
bounded. Tests honor findFiles' maxResults so a pre-ranking cap is observable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
…en tabs

Two failure paths after the child-write phase of a split:
- A provider that created a destination then rejected mid-write left an untracked
  orphan, because the delete-compensation was registered AFTER the write; a retry
  then bumped the name to a "-2" duplicate. Register the cleanup BEFORE the write,
  guarded by an exists() probe so a write that created nothing stays a no-op.
- closeNotebookTab discarded the tabGroups.close() result, so a re-dirtied tab
  whose close was cancelled left the file renamed out from under an open editor —
  a later save recreated the legacy .deepnote. It now returns the boolean (and
  closes every matching tab); the split throws before rename if it can't close.

Adds a TabInputNotebook mock (verified field/constructor-exact against the real
vscode 1.95.0 source) so the close-cancelled path can be driven in a unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
"folderless" is flagged by cspell; reword to "no open folder" instead of adding
a dictionary entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts`:
- Line 69: Update the project integration discovery flow around the findFiles
call and its returned activePersisted/siblingsFailed result to track discovery
failures separately. Propagate that failure through the result so the webview
can warn that sibling propagation may be incomplete, rather than reporting full
success when a workspace folder scan fails.
- Around line 94-97: After re-reading the project in the save flow, validate
that projectData.project.id still matches projectId before assigning
integrations in projectIntegrationsWriter. If it does not match, stop without
writing; preserve the existing integrations-only update for the matching
project.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a76c43a-f082-41dd-8d9b-261de2f5f984

📥 Commits

Reviewing files that changed from the base of the PR and between c684269 and 578885f.

📒 Files selected for processing (12)
  • src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts
  • src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts
  • src/notebooks/deepnote/integrations/integrationManager.ts
  • src/notebooks/deepnote/integrations/integrationWebview.ts
  • src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts
  • src/notebooks/deepnote/integrations/projectIntegrationsWriter.ts
  • src/notebooks/deepnote/integrations/projectIntegrationsWriter.unit.test.ts
  • src/notebooks/deepnote/integrations/types.ts
  • src/notebooks/deepnote/snapshots/snapshotService.ts
  • src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts
  • src/test/mocks/vsc/index.ts
  • src/test/vscode-mock.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/notebooks/deepnote/integrations/integrationManager.ts
  • src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts
  • src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts
  • src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts
  • src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts
  • src/notebooks/deepnote/snapshots/snapshotService.ts

The integration writer checked project.id before flushing the open
document, but the flush can save a stale dirty tab whose on-disk file
was swapped to a different project. The post-flush re-read was then
written without re-checking identity, so integrations could land in the
wrong project's file. Re-validate after the re-read and skip on mismatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
@tkislan
tkislan requested a review from dinohamzic July 16, 2026 15:39
@tkislan
tkislan merged commit 652de06 into main Jul 17, 2026
14 checks passed
@tkislan
tkislan deleted the tk/single-notebook branch July 17, 2026 08:49
tkislan added a commit that referenced this pull request Jul 17, 2026
Resolves conflicts from the single-notebook refactor (#429) against the
telemetry PR:

- Explorer/environments/integration/activation constructors: keep both
  sides' injected params (added `analytics: ITelemetryService`).
- DeepnoteExplorerView adopts main's refactored command handlers and
  re-applies telemetry: methods return `boolean` so the command wrappers
  record `{ completed }` (open/create/import/delete/duplicate/export).
- Dropped `delete_project` and project-level `export_notebook` telemetry:
  #429 removed the deleteProject/exportProject commands, methods, and
  package.json menus entirely. Removed the now-unused `delete_project`
  from the TelemetryEventName union.
- deepnoteCellExecutionAnalytics: use main's
  `getProjectForNotebook(projectId, notebookId)` (was `getOriginalProject`).
- package-lock.json: main's lockfile + posthog-node@4.18.0.

typecheck 0, lint 0, compile-tsc 0, unit tests 2502 passing / 0 failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGod1izfU9yA8ZQfxD5yeZ
tkislan added a commit that referenced this pull request Jul 17, 2026
Brings in the squash-merged PR #429 ("one notebook per .deepnote file"). This
branch forked from an earlier tk/single-notebook, so #429's later work surfaced
as conflicts in shared files. Resolved by taking #429's newer versions of the
shared single-notebook code (persistProjectIntegrations refactor with
activeFileUri, snapshot candidate ranking, abortable tab-close) while preserving
this PR's env-file integration additions and unioning divergent imports.

Compiles clean; full unit suite green (2543 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPTs6CHNncauUuTGkwpNtH
tkislan added a commit that referenced this pull request Aug 11, 2026
Collapse the six processService.exec call sites into a single runPython
helper that forwards the token and re-checks it afterwards, so a new exec
cannot silently omit either. Removes seven now-redundant throwIfCanceled
calls and three restatements of the same rationale comment.

Deduplicate the two identical kernel-spec try/catch blocks into
tryInstallKernelSpec; mutation testing showed one of the two cancellation
guards was covered by no test at all.

Add resolvePythonExecutable to the runtime-core test mock. Merging main
brought in the ESM loader interception from #429, which lacks that export,
so loading the installer aborted the whole unit suite at import time.

Extend the cancellation tests from 3 to 9: the existing ones only asserted
token forwarding, leaving the cancellation outcomes untested. Each new test
was verified to fail when its fix is reverted.

Drop the `ex as Error` casts (useUnknownInCatchVariables is off, so they
were no-ops) and trim comments that restated the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k
tkislan added a commit that referenced this pull request Aug 12, 2026
* fix: forward cancellation token to toolkit installer subprocesses

The pip/venv exec calls in the Deepnote toolkit installer were started without the CancellationToken, so cancellation was only checked between calls. Cancelling during a multi-minute pip install did nothing until the install finished, leaving the Stop button unresponsive.

Pass the token into every processService.exec call (and thread it through isToolkitInstalled) so cancelling now terminates the running subprocess immediately.

* fix: handle cancellation outcomes now that execs are killable

ProcessService.exec resolves with partial output when the token kills
the process (only shellExec rejects), so forwarding the token exposed
several paths that misread a cancelled exec as a domain result:

- rethrow CancellationError unwrapped from installVenvAndToolkit's
  catch so upstream isCancellationError checks suppress the error UI
  instead of showing an install failure
- make isToolkitInstalled cancellation-aware: throw on cancel after the
  probe exec instead of returning undefined, which misdiagnosed healthy
  venvs as toolkit-missing and successful installs as failed verification
- require the token parameter on isToolkitInstalled so future callers
  cannot silently reintroduce an uncancellable probe
- check for kernel.json rather than the kernelspec directory and
  re-check the token after the ipykernel exec, so a cancelled install
  cannot leave a permanently trusted partial kernelspec
- re-check the token in installAdditionalPackages before reporting
  success, and log cancellation instead of a failure message

Rewrite the unit test on the repo's ts-mockito pattern (capture/verify,
deepStrictEqual per CLAUDE.md) and cover the ensureVenvAndToolkit probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: route toolkit installer execs through one cancellable helper

Collapse the six processService.exec call sites into a single runPython
helper that forwards the token and re-checks it afterwards, so a new exec
cannot silently omit either. Removes seven now-redundant throwIfCanceled
calls and three restatements of the same rationale comment.

Deduplicate the two identical kernel-spec try/catch blocks into
tryInstallKernelSpec; mutation testing showed one of the two cancellation
guards was covered by no test at all.

Add resolvePythonExecutable to the runtime-core test mock. Merging main
brought in the ESM loader interception from #429, which lacks that export,
so loading the installer aborted the whole unit suite at import time.

Extend the cancellation tests from 3 to 9: the existing ones only asserted
token forwarding, leaving the cancellation outcomes untested. Each new test
was verified to fail when its fix is reverted.

Drop the `ex as Error` casts (useUnknownInCatchVariables is off, so they
were no-ops) and trim comments that restated the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k

* test: use US spelling in a test name to satisfy cspell

cspell.json sets "language": "en", so the en-US dictionary rejected
"recognised" in a test title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k

* fix: reap the whole subprocess tree when an installer exec is cancelled

Forwarding the token made the installer's subprocesses killable, but
ProcessService.kill only signals the process we spawned. `python -m venv`
runs ensurepip in a subprocess and pip shells out to build backends, so
cancelling left survivors writing into the venv that the retry path is
busy deleting recursively.

exec now opts into its own process group when the caller asks for
`detached`, and kills that group instead of the single pid. Windows is
unchanged by construction: taskkill /T already reaps the tree, and
`detached` there pops a console window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k

* fix: stop reporting a cancelled toolkit install as a failure

Now that the execs are actually killable, cancelling surfaces immediately
instead of after pip has finished anyway, so the unguarded catches around
the install and kernel-selection flows raise "Failed to install
deepnote-toolkit: Canceled" and "Failed to load Deepnote kernel: Canceled"
on every Stop. Neither is a failure the user needs to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k

* style: apply new-file conventions to the proc cancellation test

Drop the Microsoft copyright header and move the vscode-path import into
the local-import group, matching every other file this branch adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeHawfLMtvXn252EkCip2k

* Fix spell check

---------

Co-authored-by: Marko Paleka <slightly@Markos-MacBook-Air.local>
Co-authored-by: Jakub Jurových <jakub.jurovych@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: tomas <tomas@kislan.sk>
Co-authored-by: Dino Hamzić <dino@subtlebits.com>
tkislan added a commit that referenced this pull request Aug 24, 2026
Resolves 4 conflicting files. The branch's interpreter-based direction is kept;
main's changes to the same code are carried forward:

deepnoteServerStarter.node.ts
- Keep IInstaller/Product.deepnoteToolkit and deriveEnvPath (branch); take
  applyIntegrationEndpointEnv over the deleted gatherSqlIntegrationEnvVars (#440).

deepnoteKernelAutoSelector.node.ts
- Drop onKernelStarted/init staging: IDeepnoteInitNotebookRunner no longer exists,
  the runner subscribes to kernel start itself (#429).
- getOriginalProject -> getProjectForNotebook (#429).
- Collapse baseFileUri/projectKey/notebookKey to getNotebookKey() (#429).
- Replace the local projectServerHandles map with IServerHandleRegistry, keeping
  #429's lifecycle: the old handle is unregistered only after a successful switch.
- Restore the controller identity check (#358) and findNotebookEditor, so
  notebook.selectKernel gets a NotebookEditor rather than a NotebookDocument (#430).
- Restore the cancellation early-return in handleKernelSelectionError (#409).
- Placeholder controllers and the select_environment telemetry call site stay
  removed: both existed only to drive the environment picker this branch deletes.

Tests
- Port main's server-handle lifecycle, controller-identity and cancellation tests;
  drop the environment-picker, placeholder and telemetry tests along with the code.
- Stub the Python API in the DeepnoteServerStarter suite, which now reaches
  deriveEnvPath -> getCachedEnvironment.

Verified: typecheck 0, compile 0, 2774 unit tests passing, 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx
tkislan added a commit that referenced this pull request Sep 24, 2026
…n suites

Five suites each carried their own copy of the same `workspace.fs`
fake: serve `.deepnote` files by `fsPath`, reject any other read, and
parse every write into a map. The writer suite's `stubWorkspace` (#429)
was the original; this branch added four more copies: the picker's
`stubWorkspace`, the manager suite's `setup()`, `stubProjectFileOnDisk`
and `stubProjectFiles`.

`stubDeepnoteFiles` in `src/test/mocks/vscodeFs.ts` now holds that
core. It is built on `stubReadFile`, which now rejects a URI its
resolver returns `undefined` for (its two callers always return a
string). The options cover what the copies added: `failWriteFor`
(writer, manager), `onRead` (picker) and `onWrite` (writer). Each suite
keeps its own `findFiles`, workspace-folder and open-notebook wiring,
which is where they really differ.

The manager suite's failed writes now reject with "write failed for"
instead of "write blocked for"; no test reads the message.

The helper uses `fsPath`, which `local-rules/dont-use-fspath` bans
outside `.node.ts` and `.test.ts` files; like the three
`src/test/datascience` helpers, the file disables the rule.

Verified:
- `npm test`: 2879 passing, 239 pending, as before. In the five suites
  and the `stubReadFile` callers, the same 175 tests pass, by title.
- `npm run typecheck`, `lint` (the same 27 existing warnings as HEAD),
  `format` and `spell-check` exit 0.
- The suites go through the helper: dropping the write capture fails
  19 tests across all five suites; ignoring `failWriteFor` fails 3
  writer and 2 manager tests; reading it only at stub time fails the 2
  manager tests that add a failure after setup; dropping `onWrite`
  fails the writer's cache-ordering test; dropping `onRead` fails the
  picker's 2 mid-scan cancellation tests.
- Not pinned by a test: serving an unknown path as an empty file
  instead of rejecting passes everything, because the code under test
  treats an unparsable file like an unreadable one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv
tkislan added a commit that referenced this pull request Oct 7, 2026
… integrations across projects (#516)

* feat(integrations): add an "Add Existing Integration" picker to reuse integrations across projects

Integrations created through the extension UI are stored in SecretStorage
keyed by integration id, but each project's `.deepnote` roster decides which
of them apply to it, so the same database had to be configured again for
every project in a workspace.

Add `deepnote.addExistingIntegration` (command palette, and an "Add Existing
Integration" button in the Manage Integrations panel). It scans the
workspace's `.deepnote` files for integrations other projects declare that
have a stored config, offers them in a QuickPick (name, type, which projects
use them), and links the chosen one into the active project's roster through
`persistProjectIntegrations`. Credentials are not copied: the roster entry is
the only per-project scoping, so the linked project resolves the same config
(and federated refresh token). Running kernels of the project get an env
refresh and the panel is re-shown, since no storage change event fires for a
roster-only edit.

Integrations already on the roster, file-only (`.deepnote.env.yaml`) ones and
ids whose roster type disagrees with the stored config are not offered; the
last case is reported with a warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(integrations): keep the full project roster when attaching an existing integration

`getCachedRoster` narrowed the cached roster to the types the panel can manage and
`attachExistingIntegration` persisted that narrowed array, so linking an integration
silently dropped `pandas-dataframe` (and any type this build does not know) from the
project's integrations. The roster now passes through verbatim, following the same
cast-not-narrow pattern `SqlCellStatusBarProvider.addToProjectIntegrations` already
uses, and only the picker's exclusion set is derived from it.

Adds a picker test and a manager test asserting a `pandas-dataframe` entry survives
the attach in both the cache update and the written file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(integrations): trim comments to carry why, not what

Drops comments that restated the code they sat on, condenses the
docstrings that explained storage internals at tutorial length, and
states the "reuse is a link, not a copy" rationale once in
collectReusableIntegrations instead of in three places.

Also moves add_existing_integration to its alphabetical slot in
TelemetryEventProperties. It had been inserted between the
"No `outcome`: ..." docblock and refresh_integration_env, so that block
read as documenting an event that does carry an outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjAiQLqjuPiZmN9GiTXCfd

* fix(integrations): close data-loss windows in add existing integration

Addresses the review of this branch.

Correctness:
- Re-read the project's integrations after the QuickPick instead of writing
  back a snapshot taken before it. The file watcher replaces the cached
  project on an integrations-only external write without any UI event, so
  the pre-pick array could be stale, and the writer stamps whatever it is
  given onto every .deepnote file of the project.
- Refuse the command on a *.snapshot.deepnote file. Snapshots match the
  notebook selector and the writer skips them, so the command reported a
  failure only after it had already updated the cache.
- getCachedProjectIntegrations returns undefined on a cache miss rather than
  an empty array, so "not cached" can no longer be written back as "no
  integrations at all".
- Make the workspace scan cancellable: window.withProgress plumbs a token
  into collectReusableIntegrations, findFiles and both scan loops.

Types:
- Widen the write path to RawProjectIntegration, now defined once in
  notebooks/types.ts. This removes both `as ProjectIntegration[]` assertions
  and two duplicate local declarations of the type. Narrowing inside the
  writer is now a compile error rather than a silently exhaustive switch.

Naming:
- "roster" becomes "project integrations" throughout. In the SQL status bar
  the narrower and wider lists are now projectIntegrations and
  selectableIntegrations, matching getSelectableIntegrations.

Localization:
- Integration type labels were written out in seven places, one of which
  (the webview map) was never localized at all. They now live only in
  localize.Integrations.typeLabels, keyed by a bundle key derived from the
  integration type so there is no second list to keep in sync. Drops the
  dead integrationsDuckDBTypeLabel.

Tests:
- Cover every failure branch of addExistingIntegration, plus the stale
  re-read, the snapshot guard, the cache miss and both cancellation paths.
  Each new test was verified to fail against the unfixed code.
- Type refreshSpy off IIntegrationEnvLiveRefresher so a signature change
  fails the compile, not just the runtime assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* fix(integrations): validate notebook, cache and scan state in add existing integration

Three places where the command acted on state it had not re-derived at the
moment it acted:

- The panel posts the notebook URI it was opened for, and nothing clears it
  when that notebook closes. `resolveDeepnoteNotebook` then fell through to
  the focus fallbacks, so the pick was written into whichever other project
  happened to be visible, along with its siblings and a kernel env refresh.
  Reject a supplied URI that no longer resolves; the palette path passes no
  URI and still needs the fallback.

- `persistProjectIntegrations` updates the cache before it writes, so a failed
  write left the link in the roster. The retry then excluded that id and
  reported nothing available, while the file still lacked it. Restore the
  pre-write roster on the failure branch.

- `collectReusableIntegrations` only checked the cancellation token at loop
  heads, so a cancel during the last file, or during `findFiles`, returned
  `cancelled: false`. That opened an unwanted picker and, in a single-root
  workspace, claimed no integrations were available after an unfinished scan.
  Recheck the token before assembling the result.

Each fix has a regression test that was seen to fail against the unfixed code.
The picker test harness now models `findFiles` resolving empty on a tripped
token, which is what let the scan bug go uncovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* refactor(integrations): throw CancellationError from the reusable-integration scan

`collectReusableIntegrations` signalled cancellation with a `cancelled` flag
alongside two fields that were always empty when it was set — a sum type
encoded as a product. The cost showed up as three identical dead literals and
two doc comments whose only job was to warn that the other fields were unsafe
to read.

It also made the failure silent by default: a caller that forgets to check the
flag proceeds on an empty roster and reports "no integrations available" after
an unfinished scan, which is the bug fixed in the previous commit. A throw
cannot be forgotten.

Use the helpers the repo already has — `Cancellation.throwIfCanceled` and
`isCancellationError` from `platform/common/cancellation`, as the kernel layer
does — so the result type is now always meaningful and both warnings are gone.
The only caller handles it in the try/catch that AGENTS.md already prescribes
around `withProgress`, and rethrows anything that is not a cancellation, which
is what happened before.

Verified by mutation: neutralising the three guards fails exactly the three
cancellation tests and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* refactor(localize): reorder imports and improve comment clarity in Integrations namespace

* test(e2e): cover the add-existing-integration command end to end

Drives the real reuse flow in one temp workspace holding two fixture
projects: runs the command on "Quick Notes" while the sibling project
only declares an integration it never configured, configures a
PostgreSQL integration through the integrations panel, then reuses it
from "Sales Analytics".

Seeding goes through the panel because the picker offers only ids with a
config in SecretStorage, and the panel's Save is its one writer. A
`.deepnote.env.yaml` cannot stand in: file-only integrations are
excluded from the picker by design, since they already apply to every
project under the file.

Asserts the rules a unit test cannot reach end to end:
- a declaration without stored credentials is not offered
- the picked row carries the name, the type label and "Used in: <project>"
- the target project's file gains the link and KEEPS the integration it
  already declared
- the credentials never reach that file

Both halves were verified to fail against mutated builds: dropping the
existing entries in attachExistingIntegration fails the roster
assertion, and offering entries with no stored config fails the
"nothing to reuse" case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* test(e2e): collapse the add-existing-integration assertions into one test

The five `it()` blocks shared a single `before()` hook and only read
state it had already captured, so the split bought separate mocha
records and nothing else: the hook takes ~35s, all five assertion
bodies together took 296ms in CI.

Each former test name stays as a section comment, and the `expect`
message strings still name every assertion in failure output. The
tradeoff is fail-fast: mocha now stops at the first broken expectation
instead of reporting the others alongside it.

Verified by running the suite headlessly — 1 passing (35s), with all
four scenario screenshots produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* refactor(integrations): merge an added integration into each file on write

The writer replaced `project.integrations` wholesale with an array its
caller had built, so both callers that only wanted to append one entry
had to do their own read-modify-write and own the freshness of that
snapshot. `addExistingIntegration` re-read the cache after the pick to
keep its snapshot fresh, which narrowed the window rather than closing
it: four awaits still sat between that read and `workspace.fs.writeFile`,
and anything added to the project in between was stamped away — from
every `.deepnote` file of the project at once.

`writeIntegrationsToFile` already re-reads each file from disk, so it now
builds the array to write from that read via a resolver.
`persistProjectIntegrations` passes `() => integrations` and is unchanged
byte for byte, so the panel's replace path (how deletes happen) keeps its
semantics. The new `addProjectIntegration` passes the merge, and the two
append callers use it.

Drops from `addExistingIntegration`: the second cache read, its guard,
and the cache rollback. `getCachedProjectIntegrations` is now only the
scan's exclusion set, where staleness is harmless.

Two behavior changes:
- The base is the file, not a caller snapshot, so the read and the write
  are adjacent with only the merge between them. Siblings that had
  already diverged keep their own entries instead of being flattened.
- The cache moves only once the active file is on disk. Previously it
  moved first; `addExistingIntegration` rolled it back on failure while
  `sqlCellStatusBarProvider` did not, leaving the cache holding an
  integration that never reached disk.

The new regression test was verified to fail against a mutated writer
that ignores what the file holds: it wrote [pg-shared] instead of
[added-meanwhile, pg-shared]. The SQL status bar test previously stubbed
no filesystem at all and passed because of the bug above; it now has a
readable project file and asserts the entry reaches disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* fix(integrations): move the cache update ahead of the sibling sweep

f82d19741 moved the cache update to after the whole sibling sweep, which
is the slow half of the write: findFiles plus a read and a write per
file. Saving a notebook rebuilds its entire `.deepnote` from the cached
project (`deepnoteSerializer` reads the cache, stores it back, then
serializes it), so a save landing inside that window wrote the pre-add
roster straight back over the active file, and the watcher reloaded the
cache from that. End state: siblings carried the link, the file the user
was working in did not, and the success toast had already been shown.

Split the sweep out of `writeProjectFiles` so the cache moves between the
active write and the siblings. Both invariants hold: nothing reaches the
cache that did not reach the disk, and the write is the last await before
the cache moves. The sweep still runs when the active write fails, as
before.

The new test asserts the order is active-write, cache, sibling-write; it
was verified to fail against f82d19741's ordering and nothing else did.

Also: the doc comment on `writeIntegrationsToFile` still described the
string outcomes it stopped returning, the log prefixes still said
`persistProjectIntegrations` on lines the new entry point also reaches,
and one test message claimed nothing reached disk in a case that now
writes the file before the cache throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* fix(sql): refuse to switch the integration of a snapshot notebook

`*.snapshot.deepnote` matches the notebook selector, so the SQL cell's
integration picker is reachable on a snapshot. Switching there edited the
record's own cell metadata and, through `addToProjectIntegrations`,
rewrote the real project files behind it — the snapshot's active write is
skipped, but the sibling sweep still stamps every matching `.deepnote`.

`addExistingIntegration` has guarded this since 058fe8ffa; this path
never did. Guard at the top of `switchIntegration` rather than inside
`addToProjectIntegrations`, so the cell metadata edit is refused too.

The test arranges the project, the pick and the edit so the switch would
otherwise go through, leaving the snapshot check as the only thing that
can stop it; it was verified to fail with the guard disabled. An earlier
version of it passed with the guard disabled, because an unstubbed
project sent it down the "Project not found" return instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* test(integrations): drop duplicated manager tests, pin the error each branch shows

Three tests in the addExistingIntegration suite asserted claims that
already live in `projectIntegrationsWriter`/`existingIntegrationPicker`,
and one assertion in them could not fail at all:

- the pandas-dataframe case is the picker's "passes entries of types it
  cannot manage through verbatim" one level up. Its non-duplicate
  assertion — that DuckDB is "neither offered nor a candidate" — holds
  because `storedConfigs` has no entry for that id, so it still passed
  with both the self-project skip and the configurable-type filter
  removed.
- "leaves the cache alone when the active file cannot be written" is
  `projectIntegrationsWriter.unit.test.ts`'s test of the same name; the
  ordering it pins is `addProjectIntegration`'s alone. The claim moves
  into the `lateFailures` body, where it holds for both rows.
- the two mid-pick tests drive one path. The fs stub reads
  `onDiskCurrent ?? currentProject`, so moving the cache moves disk too,
  and the cache is read once at the top of the command, before the scan —
  a mutation during the pick is unobservable. The disk-only test is the
  stronger of the two and stays.

The two standalone early-failure tests assert a subset of the table that
followed them, so they become rows in it.

`showErrorMessage(anything())` let every early failure claim any
diagnosis: pointing the snapshot branch, and then the notebook-closed
branch, at the generic failure text passed the whole suite. Each row now
names the message it expects, and both mutations were seen to fail.

18 tests -> 15. Verified the rest of the coverage survives: the merge,
cache-ordering, snapshot-guard and stale-panel mutations each still fail
the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* fix(integrations): do not sweep siblings when the active write did not land

`addProjectIntegration` swept every sibling `.deepnote` whatever the
active file returned. `writeIntegrationsToFile` has four ways not to
write: a snapshot, a file whose project id does not match, one whose
unsaved edits cannot be flushed, and a rejected write. In all four the
sweep still stamped the integration across the project's real files on
behalf of a file that never took it.

The snapshot case is the one that has already cost two fixes — 058fe8ffa
and ab1a81e8d both guard at a caller, so the next caller to reach this
function gets no protection. `failed` is the milder half: the siblings
end up carrying a link the active file and the cache do not, while the
user is told the add failed. Return before the sweep instead, so the
active file is the single mandate for touching anything.

`siblingsFailed: 0` on that return is safe: both callers read it only
when `activePersisted` is true.

The regression tests cover both halves — a refused active write and a
skipped snapshot, each with a sibling on disk — and were seen to fail
against the old code on `writes.size`, with the sibling written.

`persistProjectIntegrations` keeps its own shape: it updates the cache
before the active write by design, so the same early return there is a
larger change and is left for its own commit.

Also carries a rename of `active` to `writeOutcome` that was already in
the tree, on the lines this touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ns2d146xs51a6E8LeDgJQS

* refactor(integrations): narrow the scan's catch variable, not assert it

`addExistingIntegration` passed the scan's catch variable to
`isCancellationError` as `error as Error` (added in d3ea64c9a). With
`useUnknownInCatchVariables` off in tsconfig.base.json, `error` is
`any`, so the assertion claims an `Error` without checking for one. It
would also keep compiling, and mask `unknown`, if that flag were turned
on.

`error instanceof Error && isCancellationError(error)` checks what the
assertion only claimed. It gives the call the same static type, stays
correct under `useUnknownInCatchVariables`, and matches the existing
idiom in deepnoteKernelAutoSelector.node.ts. The scan throws nothing but
the `CancellationError` from `Cancellation.throwIfCanceled`, so every
outcome it can produce is unchanged: a cancellation still returns
`cancelled`, and anything else is still rethrown.

No test is added. HEAD and this change differ only for a non-`Error` or
cross-realm value that looks like a cancellation, which the scan cannot
throw, so a test pinning that difference would only detect the change.

Verified:
- npm run typecheck: exit 0
- npm run lint: exit 0, no findings in integrationManager.ts
- npx prettier --write / --check on integrationManager.ts: no changes,
  check passes
- npm run compile-tsc: exit 0
- npm run test:unittests -- --grep
  "IntegrationManager.addExistingIntegration": 15 passing before and
  after the change
- grep -n "as Error" integrationManager.ts: no matches
- mutation: with the guard narrowed to `instanceof TypeError`, "returns
  cancelled and writes nothing when the scan is cancelled" failed on
  the `CancellationError` rethrown from `throwIfCanceled`, so the
  existing test exercises the new condition; it passes again with the
  change restored

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): report cancellations in add_existing_integration

`CommandOutcome` exists so telemetry can separate user drop-off from
real failures, and `add_existing_integration` is typed with it. But
`addExistingIntegration` left both of its cancel exits, a cancelled
scan and a dismissed picker, before the only `trackEvent` call, so the
event could only ever carry 'completed' or 'failed' and drop-off in
this command was never measured. `split_notebook`, the explorer
commands and `authenticate_integration` all report 'cancelled'.

Both cancel exits now send the event through a private
`trackAddExistingIntegration(outcome, integrationType?)`, which the
final send uses too. Nothing has been picked on either cancel exit, so
the type falls back to 'unknown', the placeholder
`integrationWebview.ts` already uses when no type is known.

The five early-failure exits and the "none available" exit stay
untracked, as before. Counting "none available" as 'completed' would
record completions where nothing was linked, so that needs its own
product call.

The two cancel tests pinned the old silence with
`trackEvent(anything()).never()`. Each now asserts exactly one event,
`trackEvent(anything()).once()`, and that it is the 'unknown' /
'cancelled' one, `trackEvent(deepEqual(...)).once()`. The count check
is what rejects a stray second event.

Verified:
- npm run typecheck: exit 0
- npm run lint: exit 0, no findings in the changed files
- npx prettier --write / --check on both files: no changes, check
  passes; npm run format: exit 0
- npm run compile-tsc, then npm run test:unittests -- --grep
  "IntegrationManager.addExistingIntegration": 15 passing. Against the
  unfixed manager, both updated tests failed with `Expected
  "trackEvent(anything())" to be called 1 time(s). But has been called
  0 time(s).`
- Mutations, each recompiled and run, then edited back:
  - a stray 'failed' event after the dismissal's 'cancelled' one:
    the picker test fails, trackEvent(anything()) called 2 time(s)
  - the dismissal tracked twice: the picker test fails the same way
  - the dismissal tracked as 'completed': the picker test fails,
    trackEvent(deepEqual(...)) called 0 time(s)
  - the scan cancel tracked as 'failed': the scan test fails,
    trackEvent(deepEqual(...)) called 0 time(s)
- npm test: 2864 passing, 239 pending, 0 failing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): re-show the panel before the kernel env refresh

After a link lands, `addExistingIntegration` shows the success toast
and awaits `refreshAfterProjectIntegrationsChange`, which awaited the
kernel env refresh before it re-showed the panel. The refresh runs
`executeHidden` in every running kernel of the project, and a kernel
busy with a running cell answers only after that cell finishes. Until
then the panel kept its pre-link list, and a save, reset or delete
from it rewrote the cache and every project file from that list,
silently dropping the link the user had just been told was added.
With no panel open, the panel opened only once the kernels were free.

The panel is now re-shown first and the kernels are refreshed after.
Each call keeps its own try/catch, so a failed re-show still refreshes
the kernels, and both stay awaited. Step 4 of "Add Existing
Integration" in specs/INTEGRATIONS_CREDENTIALS.md now gives the order
and the reason for it.

By reading, this narrows the window but does not close it: the panel
is still on the old list while the writer sweeps the project's sibling
files, after the cache update and before the re-show. That wait is
bounded by local I/O, and its cause, the panel writing back a snapshot
that replaces the whole list, predates this branch.

Verified:
- npm run typecheck: exit 0
- npm run lint: exit 0, no findings in the changed files
- npx prettier --write / --check on the three files: no changes,
  check passes
- npm run compile-tsc, then npm run test:unittests -- --grep
  "IntegrationManager.addExistingIntegration": 15 passing before,
  17 passing after. Against the unfixed order, 're-shows the panel
  before the kernel env refresh settles' failed with `Expected
  "show(strictEqual(project-current), anything(), anything(),
  anything(), anything())" to be called 1 time(s). But has been
  called 0 time(s).` 'still refreshes the kernels when re-showing the
  panel fails' passed there: it guards the new order and does not
  reproduce the bug.
- Mutations, each recompiled and run, then edited back:
  - the old order put back: the first new test fails the same way
  - panel first without its try/catch: the second new test fails
    with `expected [] to deeply equal [ [ [ { …(11) } ], …(1) ] ]`
- npm test: 2866 passing, 239 pending, 0 failing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): do not delete credentials another project declares

Credentials are keyed by integration id alone, and SecretStorage is
shared machine-wide, so once "Add Existing Integration" links one
project's integration into another, both resolve the same config and
federated token. Delete in either panel still removed both, and the
other project silently lost its credentials: its SQL cells fell back
to "(configure)", the storage event refreshed its kernels without the
env vars, and the picker stopped offering the id. The confirmation
only promises removal "from your project".

`deleteConfiguration` now asks `findOtherProjectsDeclaring` first.
While another project in the open workspace folders still declares
the id, Delete only takes it off this project's roster and names the
projects that keep the credentials. An unlink changes no credential,
so no storage event refreshes kernels; the panel refreshes this
project's running kernels itself, through the live refresher injected
`@optional()` as `IntegrationManager` does, since the web extension
binds none. Only open folders are scanned, so a linking project
outside them still loses the credentials; the doc comment says so.

The workspace walk moves out of `collectReusableIntegrations` into
`forEachOtherProjectFile`, shared by both callers. The picker's rules
and its existing tests are unchanged; only the walk's log prefix now
names the module. The spec notes what Delete does to a linked
integration.
`resetConfiguration` clears the same shared credentials and is left
for a follow-up.

Verified: typecheck and lint exit 0, with no lint findings in the
changed files; prettier clean on them; compile-tsc exit 0.
IntegrationWebviewProvider|existingIntegrationPicker: 49 passing
before, 53 after. The manager, writer and refresh-handler suites: 30
passing. Full unit suite: 2868 passing, 239 pending, 0 failing.

Mutations, each restored and recompiled after:
- guard removed, the old unconditional delete: only the unlink test
  fails, "Expected delete(anything()) to be called 0 time(s). But has
  been called 1 time(s).";
- refresh call removed: only the refresh test fails, "expected spy to
  be called once and with exact arguments";
- a guard that never deletes: the full-delete test fails, with the two
  existing delete token tests;
- own files counted, snapshots counted, or the dedupe dropped: the
  project-name test fails with 'Current', 'Gamma' or a second 'Alpha'.

A scratch inversify check resolves the provider with no refresher
bound, as on web, and injects a bound one, as on node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): hold back the type cards until their labels arrive

058fe8ffa replaced the webview's hard-coded English type labels with
lookups in the string bundle the host posts as `loc_init`, falling back
to the raw type id. The panel mounted `IntegrationTypeSelector` before
that message arrived, and `storeLocStrings` sets no React state, so all
18 cards were first rendered as `clickhouse`, `pgsql`, `sql-server` and
so on, until the first `update` re-rendered the panel. The doc comment
on `integrationTypeLabel` said that fallback should never render.

The panel now renders the selector only once it has handled
`loc_init`, and the doc comment says so. The order inside that case
matters and no test pins it: the panel is a legacy `ReactDOM.render`
root on React 16, so `setLocStringsLoaded` re-renders synchronously
inside the message listener and must run after `storeLocStrings`, or
the cards render before the bundle is stored. A one-line comment above
the two calls records this.

The gate cannot strand the selector: `show()` is the panel's only
creation site and always posts `loc_init` before `update`, and no
serializer revives the panel. The "Add Existing Integration" button
now waits for `loc_init` too; the e2e helper `awaitIntegrationsPanel`
polls for `.integration-type-selector`, so the gate only lengthens its
wait.

Verified: `npm run typecheck` exit 0; `npm run lint` exit 0, no
findings in the changed files; `prettier --check` clean on both files;
`npm run esbuild-all` exit 0, integrations bundle built.

No test is added. A throwaway jsdom harness outside the repo bundled
this `IntegrationPanel.tsx` and replayed the host's message order.
Before the change: 18 raw-id cards on first render, still raw after
`loc_init`, labelled after `update`. After it: no cards on first
render, then 18 labelled cards in the same tick as `loc_init`. With the
two `loc_init` calls swapped, the cards rendered raw ids after
`loc_init`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(e2e): drop add-existing assertions that can never fail

`waitForNotification(pattern, timeout, true)` either resolves with the
matching notification or throws: the helper rethrows when `required`,
and selenium's `wait` resolves a function condition only on a truthy
value. Mocha skips the `it` once `before` fails, so the two toast flags
that `before` set from `result !== undefined` were always `true` by the
time the `it` read them. `pickedLabel` had the same property: it
re-read the label of the row the picker wait had selected because that
label matched `INTEGRATION_NAME`. None of the three assertions could
fail, and they suggested a gate that really lives in `before`.

Await both required waits as plain statements, and drop the three
variables with their assertions. `required: true` stays: a missed toast
should stop the hook where it happened, and the success-toast wait also
orders the file reads that follow it. The label comparison inside the
picker wait stays as the real check, and so do the description and
"Used in" row assertions: the wait matches on the label only, so they
still check something.

Verified:
- `npm run compile-e2e` exit 0. It is the only script that type-checks
  test/e2e; typecheck, lint and format cover src/ only.
- `npx prettier --check` on the file: clean.
- grep: none of `noneAvailableToastShown`, `successToastShown` or
  `pickedLabel` remains in the file.
- A scratch mocha run of this tree's compiled helper over the real
  `WebDriver.wait`, faking only the notification list: a required wait
  resolves with the matching notification, and otherwise rejects with
  `TimeoutError`, never with `undefined` (5 passing). A model of the
  hook with the old flag and with a plain `await` behaves the same:
  both pass when the toast shows; when it is missing, both fail in
  "before all" with the same timeout message and the `it` body never
  runs.

No test is added: this only removes assertions that cannot fail. The
e2e suite was not run, so the mutation checks (making either toast
pattern or the label comparison unmatchable) were not done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): type the cache-update doubles off the interface

058fe8ffa widened `IDeepnoteNotebookManager.updateProjectIntegrations`
to `RawProjectIntegration[]`, whose `type` is any string, but the three
doubles that capture its calls annotated the pre-widening
`ProjectIntegration[]`, and so did their capture arrays. ts-mockito's
`thenCall` takes `(...args: any[]) => any` and checks no signature, so
nothing flagged them: the picker test that passes `pandas-dataframe`
and `some-future-type` entries through verbatim ran through a callback
whose declared type forbids them. The next contract change would slip
past them the same way.

Each double is now a `recordCacheUpdate` typed with
`UpdateProjectIntegrationsFn`, the interface method's type, named like
the existing `RefreshFn`, and its capture array holds
`RawProjectIntegration[]`. The callback parameters stay unannotated:
the alias comes from a method signature, whose parameters TypeScript
checks bivariantly, so a hand-written annotation, even the stale one,
would still compile and reopen the gap. Assertions and expected
literals are unchanged. The writer's `thenReturn(true)` stub, which
ts-mockito types, and its zero-argument ordering double, which reads no
arguments, are left as they are.

Verified:
- `npm run typecheck` exit 0 before and after.
- Mutation: with `integrations` widened to
  `readonly RawProjectIntegration[]` in src/notebooks/types.ts, the old
  doubles still typecheck (exit 0, no errors). The retyped ones fail
  with exit 2 and exactly three errors, one per file, each on the line
  that pushes into `cacheUpdates`:
  existingIntegrationPicker.unit.test.ts(376,48) TS4104,
  integrationManager.unit.test.ts(177,31) TS2345 and
  projectIntegrationsWriter.unit.test.ts(262,31) TS2345. Reverted by
  editing: typecheck exit 0.
- Bivariance, on scratch files importing the real types: the stale
  annotation against the method-derived alias compiles; the same
  callback against a plain function type fails with TS2322.
- `npm run lint` exit 0, no findings in the changed files;
  `prettier --check` clean on all three.
- `npm run compile-tsc` exit 0. The focused suites pass unchanged, 43
  before and after: existingIntegrationPicker 14,
  IntegrationManager.addExistingIntegration 17,
  persistProjectIntegrations 6, addProjectIntegration 6.
- `npm test`: 2870 passing, 239 pending, 0 failing.

No test is added: the change only touches types, and the compiler is
the check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): groom the existing-integration picker tests

Drop the test that takes the name from the stored config rather than
from the first project read. Its one project declares the id once, so
it cannot tell a first read from a later one, and the dedupe test
already expects the stored name over two declared ones.

Drop the attach test that replaces an id the file already declares.
The rule lives in `addProjectIntegration`, whose own test pins it with
the same arrangement; attaching only builds the entry and delegates.
The dropped test also checked only the file, which cannot show a stray
key on an entry: `serializeDeepnoteFile` strips unknown keys.

Merge the two remaining attach tests. Both attached the same entry and
checked the cache and the file the same way; only the entries the file
held before differed. The merged test starts from all three existing
entries, the `pandas-dataframe` and `some-future-type` ones included,
and keeps the check of the returned result. `ProjectIntegration` is no
longer used, so its import goes.

Compare whole entries in the exclusion test, not only ids. The current
project and project-a both declare `bq-oauth`, so the id is offered
whether or not the current project's own file is visited; visiting it
only adds `project-current` to `projectNames`, which the test now pins.

Give the unsupported-types test a declaration only the type check can
skip. Its one unsupported entry had no stored config, so the
missing-config branch skipped it with or without the check. Project-b
now declares `pg-shared` with a type this build cannot manage; without
the check, that declaration counts as a type conflict and hides
`pg-shared` everywhere.

Turn the three cancellation tests into one table of three rows. They
shared one skeleton and differed only in the files on disk, the hook
that trips the token and the files read before the stop. The last-file
row now also checks the reads, which its test did not.

Verified:
- `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in
  the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0.
- existingIntegrationPicker: 11 passing, down from 14 (three tests
  dropped; the table keeps three cases).
- Mutation harness of the test-grooming pass: one find/replace in the
  production code per run, compile, then the seven focused suites
  (148 tests, all passing unmutated). Newly caught:
  - the configurable-type check removed: the unsupported-types test.
    No test in the whole suite failed before.
  - the shared walk visiting the current project's own files: the
    exclusion test, besides the three that already failed.
  - `collectReusableIntegrations` alone scanning with a project id
    that matches nothing: the exclusion test. No test in the whole
    suite failed before.
  Still caught after the drops and merges:
  - the declared name winning over the stored one, or each later
    declaration renaming the candidate: the dedupe test.
  - `return` instead of `continue` on a missing stored config: the
    unsupported-types test.
  - a declared id appended again, or its old entry kept: the writer's
    replace test.
  - attach passing `projectNames` through, naming the entry after its
    id or using the integration id as the project id; the writer
    stamping one list over every file, caching the new entry alone,
    keying the cache by the integration id, never assigning the
    merged list, or pruning types it cannot manage: the merged test.
    The other tests that caught these before still do.
  - the cancellation checks: dropping the per-file check fails the
    mid-scan row; dropping the final check fails the last-file and
    discovery rows; moving it after each visited file fails the
    discovery row; re-checking only after an empty discovery fails
    the last-file row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): check what the add-existing manager tests claim

Make the early-failure rows check that no file is read. Their title
says each branch fails without reading or writing any file, but the
body checked only writes, and the fs double does not record reads. The
scan is the only step that reads files before the picker, and it runs
inside `withProgress`, so each row now verifies that `withProgress` is
never called.

Check the cache only in the late-failure row that can see it. In the
writer-throws row the cache double throws before it records anything,
so `cacheUpdates` stayed empty whatever the manager did. A row field
now limits that assertion to the failed-write row, and a comment on the
other row says why it has none.

Drop the sibling-warning test's check that the sibling file was not
written. The fs double rejects writes to that file before recording
them, so the check held whatever the manager did.

Pin the conflict warning in the type-mismatch test. It accepted any
warning, so one counting the offered integrations instead of the
skipped ones ("0 integration(s) were skipped ...") passed. The test now
expects `addExistingIntegrationConflictsSkipped(1)`; the `l10n.t` mock
formats the count, so the pinned text is the real one.

Verified:
- `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in
  the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0.
- IntegrationManager.addExistingIntegration: 17 passing, as before.
- Mutation harness of the test-grooming pass: one find/replace in the
  production code per run, compile, then the seven focused suites
  (148 tests, all passing unmutated). Newly caught:
  - the snapshot guard moved after the scan, so every `.deepnote` file
    is read before the refusal: the snapshot row. No test in the whole
    suite failed before.
  - the conflict warning counting the offered integrations: the
    type-mismatch test. None of the focused suites failed before.
  Still caught:
  - each early-failure branch by its own row: the missing-notebook,
    closed-notebook, no-ids and uncached-project messages, and the
    snapshot guard applied only when no URI is passed.
  - going back to a roster write that updates the cache first: the
    failed-write row, on the cache assertion, and the test that keeps
    an entry the file gained while the picker was open.
  - no catch around the attach: the writer-throws row.
  - a failed write showing no error, or refreshing anyway: the
    failed-write row. Completion telemetry without the integration
    type: both late-failure rows and the link test.
  - the sibling warning needing two failed siblings: the
    sibling-warning test.
  - the conflict warning needing two conflicts: the type-mismatch test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): drop the bundle-key change detector

Drop the test that pins the format of the label keys. The compiler
already fixes it: `IntegrationTypeLabelKey` is a template literal type,
`integrationTypeLabelKey` returns it, and `typeLabels` is declared
`satisfies Record<IntegrationTypeLabelKey, string>`, so a missing,
extra or misspelled key does not compile. The host and the webview
derive the key with that same function, so the only change this test
alone caught was a consistent rename of the format, which works end to
end. Its comment goes with it, and so does the `integrationTypeLabelKey`
import, which only it used; `noUnusedLocals` rejects it otherwise.

Verified:
- `npm run typecheck` exit 0, and exit 2 with TS6133 if the import is
  kept; `npm run lint` exit 0, no findings in the file;
  `npx prettier --check` clean; `npm run compile-tsc` exit 0.
- integrationTypeLabels: 1 passing, down from 2.
- Mutation harness of the test-grooming pass: one find/replace in the
  production code per run, compile, then the seven focused suites
  (147 tests, all passing unmutated).
  - the key function returning one constant key: still fails the
    label-coverage test and the SQL big-query label test.
  - the runtime predicate no longer excluding `pandas-dataframe`:
    still fails the label-coverage test.
  - a label entry removed, one added, or the prefix changed in the
    function alone: tsc still rejects each (TS1360, TS2353, TS2322).
  - the format renamed in the type, the function and all 18 keys:
    compiles, and no test in the whole suite fails (2866 passing,
    239 pending), as intended. Before, only the dropped test did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(sql): pin the snapshot refusal message

The snapshot test accepted any error message, so the guard could show
the add-existing command's `addExistingIntegrationSnapshotUnsupported`,
which `localize.ts` declares right next to this command's own
`switchIntegrationSnapshotUnsupported`: a realistic copy-paste swap.
The test now expects `switchIntegrationSnapshotUnsupported`, as the
manager's snapshot row already expects its own message.

Verified:
- `npm run typecheck` exit 0; `npm run lint` exit 0, no findings in
  the file; `npx prettier --check` clean; `npm run compile-tsc` exit 0.
- SqlCellStatusBarProvider: 67 passing, as before.
- Mutation harness of the test-grooming pass: one find/replace in the
  production code per run, compile, then the seven focused suites
  (147 tests, all passing unmutated).
  - the guard showing the add-existing message instead of its own:
    now fails the snapshot test. No test in the whole suite failed
    before.
  - the guard disabled, or showing its message but carrying on: still
    fails the snapshot test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): replace this branch's force-cast fixtures with typed values

Apply #544's treatment to the fixtures this branch added:

- The snapshot refusal test in `sqlCellStatusBarProvider.unit.test.ts`
  stubbed `getProjectForNotebook` with `{ project: { integrations } }
  as any`, the shape #544 rebuilt 18 times in the same file. It now
  returns `createDeepnoteFile({ project: createDeepnoteProject(...) })`
  with the same integrations array.
- The closed-notebook case in `integrationManager.unit.test.ts` built
  its visible editor as `{ notebook } as NotebookEditor`. It is now a
  ts-mockito `mock<NotebookEditor>()` instance whose `notebook` returns
  the same document, as other suites stub editors.
- The `addProjectIntegration` tests read `writes.get(uri)!.project`;
  they use `?.` now, so a missing write fails the deep-equal instead
  of throwing a TypeError.

Left as is: the snapshot test's `showQuickPick` stub keeps `as any`,
like the 15 identical stubs #544 left in that file, and the
`persistProjectIntegrations` suite's `!` reads, which predate this
branch.

Verified:
- `npm run typecheck` exit 0; oxlint and prettier clean on the three
  files.
- SqlCellStatusBarProvider, IntegrationManager.addExistingIntegration
  and the writer suites: 96 passing, as before.
- The fixtures still reach the code under test: making the snapshot
  guard carry on after its message, or dropping the manager's
  closed-notebook check, fails exactly the rewritten test in each
  suite (quick pick shown; 'completed' instead of 'failed').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): move each notebook's cached list with its own file's write

`addProjectIntegration` merged into each file on disk, but then stamped
the active file's merged list into every cached notebook of the project.
Saving a notebook rebuilds its whole file from its cache entry, so a
sibling holding entries the active file lacks lost them on its next
save. When that sibling had unsaved edits the loss was immediate: the
sweep's own flush saved it from the stamped cache before the merge
re-read the file, and the command still reported success.

The writer now updates the cache entry of the notebook open from each
file right after that file's write, with the list that file now holds
(`IDeepnoteNotebookManager.updateProjectIntegrationsForNotebook`). The
active file's entry still moves before the sibling sweep starts, and a
file that could not be written still leaves its entry alone. The
open-document lookup moves out of `flushNotebookDocumentIfDirty` into
`findOpenNotebookDocument`, so both use the same URI comparison. The
cache update sits after the disk write's try block, so a failing update
propagates as before instead of being reported as a failed write.

`persistProjectIntegrations` keeps its project-wide
`updateProjectIntegrations` for now.

Verified:
- New writer tests, run against a real `DeepnoteNotebookManager`,
  failed before the fix: a dirty sibling declaring `[int-sibling]` was
  written as `[int-added]`, and a clean sibling's cache held the
  active file's `[int-own, int-added]`. Both pass now.
- The manager, picker and SQL status bar suites now observe the
  per-notebook update (projectId, notebookId, list) instead of the
  project-wide one; DeepnoteNotebookManager gains tests for the new
  method (single entry, uncached entry, deep clone).
- Affected suites: 162 passing. `npm run compile-tsc` and
  `npm run typecheck` exit 0; oxlint and prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): edit only the panel's own entry in the project files

Every panel Save, Reset and Delete rebuilt the project's integrations
list from the panel's in-memory map and stamped it over the cache, the
active file and every sibling. The map is built by the detector, which
skips every type the panel cannot configure, and is only refreshed by
`show()`. So any panel action deleted `pandas-dataframe` and
unknown-type entries (which `RawProjectIntegration` and
`addProjectIntegration` deliberately keep), and an integration linked
while the panel was open was dropped by its next save.

Each action now edits the entry it acts on, through the same per-file
merge `addProjectIntegration` already used:

- Save adds the saved integration, or replaces its entry in place.
  Replacing in place is new: the merge used to move a re-added id to the
  end of the list.
- Delete takes the id off each file (`removeProjectIntegration`).
- Reset writes no project file; it only clears credentials.

`persistProjectIntegrations` and the project-wide
`IDeepnoteNotebookManager.updateProjectIntegrations` are gone. The
stamp was also what made a new integration reach a running kernel: the
credential save refreshes the kernels right away, and the old code
updated the cache before any await, so that refresh saw the new
integration. The list now reaches the cache only after the file write,
so a Save refreshes the project's kernels again once its file declares
the integration.

The ordering comment in the manager keeps only its real reason: a busy
kernel would hold the panel back. The specs describe the per-entry
edits, and the live-refresh spec now lists edits to the integrations
list as a refresh trigger alongside the storage and file triggers.

Verified:
- Four new panel tests failed before the change. A save wrote only
  `[pg-1]` over `[pg-1, pg-linked, duckdb, future]`, a delete wrote
  `[]`, a reset rewrote the file, and nothing refreshed the kernels
  after a save. All four pass now.
- The coverage that tested only `persistProjectIntegrations` now runs
  through `addProjectIntegration`: no open folder, an active file
  outside the folders, a failing sibling, a sibling whose flush swaps
  its project. The writer also gains an in-place test and a
  `removeProjectIntegration` suite.
- Putting back the old re-add ordering (filter, then append) fails the
  writer's and the panel's ordering tests.
- Integrations, SQL status bar and notebook manager suites: 197
  passing. `npm run typecheck` and `npm run compile-tsc` exit 0;
  oxlint and prettier clean on the changed sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): name the projects a shared reset, sign-out or save affects

Credentials and federated refresh tokens are keyed by integration id
alone, so once Add Existing Integration links an id into several
projects, a Reset, Sign out or Save in one of them acts on all of
them. Delete already checked `findOtherProjectsDeclaring` and only
unlinks. Reset and Sign out still wiped the shared credential or token
with no sign of it, and Save changed it for every project just as
silently: the other projects' SQL cells started failing, or ran
against new credentials, and nothing said why.

The actions still go ahead. Reset is how a user removes credentials,
and Save is how a shared credential gets fixed. Instead, each one now
names the other projects in its success message:

- Reset: "These credentials were shared, so this also removed them
  from: …"
- Sign out: "This sign-in was shared, so this also signed out: …"
  (Sign out posted no message before and still posts none when
  nothing else shares the token.)
- Save of an integration the panel already lists: "These credentials
  are shared, so this also changed them for: …". A brand-new
  integration has a fresh id, so it skips the scan.

The scan call moves into `findOtherProjectsSharing`, which Delete uses
too.

Verified:
- New tests (active project and "Alpha" both declare `bq-shared`):
  reset, sign-out and save each still act and name Alpha. All three
  failed before the change; a sign-out Alpha does not share posts no
  message.
- Integrations and SQL status bar suites: 192 passing.
  `npm run typecheck` exit 0; oxlint, prettier and `npm run
  spell-check` clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): never fall back to another notebook when the given one is closed

`resolveDeepnoteNotebook` looked up the notebook URI a menu or the panel
passed, and when that notebook was no longer open it fell back to the
focused editor, then to the one visible editor. Only
`addExistingIntegration` guarded against that, by comparing the result
with the URI afterwards. Manage Integrations, including the re-show after
a link, opened the panel for whatever project the focused editor
belonged to, so its next Save or Delete landed in the wrong project.

The resolver now treats a given URI as the only notebook to act on: when
it is not open, the command stops with "The notebook this was run for is
no longer open". The focused and single-visible fallbacks remain, but
only when no URI was given (the command palette). The resolver reports
its own refusal, so both commands just return, and the manager's
separate URI check is gone. The message constant loses its
Add-Existing-only name and wording now that both commands show it.

Verified:
- New Manage Integrations test: the handler, given the URI of a closed
  notebook while another is focused, opened the panel for the focused
  one before the change; it now refuses with the message. A second test
  keeps the palette path (no URI) opening the panel for the focused
  notebook.
- The Add Existing "no notebook open" case now passes no URI, matching
  the palette; with a URI it is the closed-notebook case, which still
  refuses.
- IntegrationManager suites: 19 passing. `npm run typecheck` exit 0;
  oxlint and prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): refuse snapshots once, in the notebook resolver

`*.snapshot.deepnote` matches the notebook selector, and the Manage
Integrations toolbar button is gated only on the notebook type, so the
panel opened on a snapshot. Its roster edits were all skipped (the
writer never edits a snapshot), while Save and Delete still wrote and
deleted credentials, and the user got "Failed to save integrations"
after the credentials had already changed. Add Existing Integration had
its own snapshot check; Manage Integrations had none.

`resolveDeepnoteNotebook` now refuses a snapshot for both commands,
after either lookup path, and Add Existing's own check is gone. The
message is reworded for both commands ("Integrations cannot be managed
from a snapshot file…") and renamed from its Add-Existing-only name.
The SQL cell's switch keeps its own refusal: it guards a cell-metadata
edit, not the integrations panel.

The spec's Integration Manager section now states both resolver
refusals: a closed notebook URI (previous commit) and a snapshot.

Verified:
- New Manage Integrations test: with a snapshot focused, the palette
  command opened the panel for it before the change. It now refuses
  with the message, and the panel never opens.
- The Add Existing snapshot case still refuses, now through the
  resolver.
- IntegrationManager and SqlCellStatusBarProvider: 87 passing.
  `npm run typecheck` exit 0; oxlint and prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): refresh an open panel after a link instead of opening one

After a successful link, `refreshAfterProjectIntegrationsChange` called
`showIntegrationsUI`, which reaches `IIntegrationWebviewProvider.show`.
`show` creates and focuses a panel when none is open. So running
"Deepnote: Add Existing Integration" from the command palette (it has no
menu entry) ended by opening the Integrations tab over the notebook and
taking focus. The re-show only guarded against a stale panel writing its
list back, which cannot happen when no panel is open. It no longer
happens when one is, either: panel edits now touch only their own entry.

`IIntegrationWebviewProvider` gains `refresh(projectId, integrations)`,
which re-renders a panel already open for that project without
revealing it, and does nothing otherwise. The manager detects the
project's integrations and calls it; the kernel env refresh still comes
after it, so a busy kernel cannot hold the panel back. The panel's
"Add Existing Integration" button, which runs the same command with the
panel open, now gets its list refreshed in place.

The E2E suite drives exactly this palette path. It now watches the
editor tabs for 3 s after the success message (the old command opened
the panel within a few hundred ms of it). It asserts the Integrations
tab never appeared, that the target notebook's tab was seen (so an
unreadable tab strip cannot pass the check vacuously), and that the
notebook is still the active editor. The spec's Add Existing step 4 is
updated.

Verified:
- The manager tests now expect `refresh` for this project and `show`
  never. Both refresh tests failed before the manager change (refresh
  called 0 times, since `show` ran instead) and pass now.
- New provider tests: `refresh` opens no panel, re-renders an open
  panel of the project without revealing it, and leaves another
  project's panel alone. Adding a `reveal()` or dropping the project
  check fails them.
- Integrations and SQL status bar suites: 198 passing.
  `npm run typecheck` and `npm run compile-e2e` exit 0; oxlint and
  prettier clean.
- The E2E is compiled, not run: this workspace has no display or
  Electron libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* fix(integrations): send the panel's loc strings whenever its webview starts

The type grid and the "Add Existing Integration" button render only
after `loc_init`, which the extension posted exactly once, right after
setting the panel's HTML. A message posted before the React app attaches
its listener is lost. That happens on "Developer: Reload Webviews",
after moving the tab to another window, and plausibly when the 1.6 MB
bundle takes longer than VS Code's 200 ms inner-frame fallback. The
panel was then left with no way to add or link an integration until it
was closed and reopened.

As `webviewHost.ts` does for the other webviews, the panel now posts
`started` from the effect that attaches its message listener, so once
per mount. The extension answers with the loc strings and then the
list. The one-off `loc_init` at panel creation is gone, so the loc
strings have one trigger. `show()` still posts the list straight away,
as before.

Verified:
- New host test: a `started` message is answered with `loc_init` then
  `update`. Before the change it was ignored and nothing was posted.
- Integrations suites: 132 passing. `npm run typecheck` exit 0; oxlint
  and prettier clean. `npm run esbuild-all` exit 0, and the rebuilt
  integrations bundle posts `started`.
- The webview side has no component-test setup in this repo, so its
  mount behaviour is checked only by the E2E suites that wait for the
  type grid (not run here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(e2e): treat a torn read as not yet in the declared-integration poll

`awaitDeclaredIntegrationId` read and parsed the project file every
500 ms with no try/catch, while the extension rewrites that file in
place (`workspace.fs.writeFile` is not atomic, and a flush save may
write it first). A tick that landed mid-write threw out of the `before`
hook. Mocha's `retries: 1` retries tests, not hooks, so one torn read
failed the whole suite with a parse error unrelated to the feature.

A read that fails now counts as "not written yet"; the poll carries on
until the deadline, and the timeout message includes the last read
error if there was one.

Verified:
- `npm run compile-e2e` exit 0; prettier clean.
- The failure it guards against, reproduced on the suite's own fixture:
  `deserializeDeepnoteFile` throws on an empty file ("Expected object,
  received null") and on the first half of quick-notes.deepnote
  ("Invalid discriminator value"), and parses the whole file.
- Not run: this workspace has no display or Electron libraries for
  ExTester.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

* test(integrations): share one project-file fake across the integration suites

Five suites each carried their own copy of the same `workspace.fs`
fake: serve `.deepnote` files by `fsPath`, reject any other read, and
parse every write into a map. The writer suite's `stubWorkspace` (#429)
was the original; this branch added four more copies: the picker's
`stubWorkspace`, the manager suite's `setup()`, `stubProjectFileOnDisk`
and `stubProjectFiles`.

`stubDeepnoteFiles` in `src/test/mocks/vscodeFs.ts` now holds that
core. It is built on `stubReadFile`, which now rejects a URI its
resolver returns `undefined` for (its two callers always return a
string). The options cover what the copies added: `failWriteFor`
(writer, manager), `onRead` (picker) and `onWrite` (writer). Each suite
keeps its own `findFiles`, workspace-folder and open-notebook wiring,
which is where they really differ.

The manager suite's failed writes now reject with "write failed for"
instead of "write blocked for"; no test reads the message.

The helper uses `fsPath`, which `local-rules/dont-use-fspath` bans
outside `.node.ts` and `.test.ts` files; like the three
`src/test/datascience` helpers, the file disables the rule.

Verified:
- `npm test`: 2879 passing, 239 pending, as before. In the five suites
  and the `stubReadFile` callers, the same 175 tests pass, by title.
- `npm run typecheck`, `lint` (the same 27 existing warnings as HEAD),
  `format` and `spell-check` exit 0.
- The suites go through the helper: dropping the write capture fails
  19 tests across all five suites; ignoring `failWriteFor` fails 3
  writer and 2 manager tests; reading it only at stub time fails the 2
  manager tests that add a failure after setup; dropping `onWrite`
  fails the writer's cache-ordering test; dropping `onRead` fails the
  picker's 2 mid-scan cancellation tests.
- Not pinned by a test: serving an unknown path as an empty file
  instead of rejecting passes everything, because the code under test
  treats an unparsable file like an unreadable one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiYErQ9fP3wNhrNaRZAdqv

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: tomas <tomas@kislan.sk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants